第 14 章建立了任務與 Gold,第 16 章建立了 WindDash,第 17 章讓玩家用 Gold 購買升級。現在這些進度都還只是單次 session 裡的狀態。玩家離開遊戲再回來,Gold、任務進度、升級狀態都會消失。
本章要加入第一版存檔系統。這是全書第一個真正接觸雲端資料的章節,所以節奏要放慢一點。DataStore 不是普通 table,它是網路服務:會失敗、有限制、會 throttle,也可能因為測試方式不對而污染正式資料。
本章目標不是做出完美資料平台,而是建立一個能保存目前主線進度、能處理失敗、能區分測試資料與正式資料的 SaveService。
本章資料只來自 gameplay 與 Gold 商店。第 34 章接入 Developer Product 時,會把 schema 升級到 entitlement、消耗品餘額與 processed receipt 紀錄;真實付款資料需要比一般進度更嚴格的原子更新、重試與重複交付防護。
完成本章後,你會得到:
SaveService。AIAdventureIsland_PlayerData_DEV_v1 DataStore。Gold 可以跨 session 保留。GuideQuestState 可以跨 session 保留。WindDashCooldownLevel 與 WindDashSpeedLevel 可以跨 session 保留。pcall 與錯誤訊息。
圖 18-1 Data Stores Manager 可用來檢查線上資料;測試存檔時要區分 Studio 測試資料與正式環境資料。
本章使用下列 Roblox 官方文件作為 context:
本章採用這些文件中的幾個原則:
Script 存取。Enable Studio Access to API Services,但不要在 live game 上直接測正式資料。pcall 處理失敗。
圖 18-2 發布後用 Data Store observability 觀察各 API 的請求量;異常尖峰可能代表重複存檔、重試失控或即將觸發限制。
目前 AI Adventure Island 有三類需要保存的資料:
Gold
任務狀態
升級狀態
先不要保存玩家位置、敵人狀態、目前 HUD 文字、cooldown 倒數或臨時 UI。這些資料不是長期進度,保存它們只會增加錯誤風險。
本章使用第一版 schema:
{
SchemaVersion = 1,
Gold = 0,
GuideQuestState = "NotStarted",
Upgrades = {
WindDashCooldownLevel = 0,
WindDashSpeedLevel = 0,
},
}
DataStore 名稱先用:
AIAdventureIsland_PlayerData_DEV_v1
DEV 很重要。你在 Studio 測試時會產生很多不穩定資料,甚至可能反覆清空、改 schema、手動加 Gold。如果一開始就用正式 store 名稱,很容易把測試過程變成未來難以清理的 production data。
請 Assistant 建立第一版 SaveService。
【Ch18 主任務|建立雲端存檔】
我們正在繼續製作 Roblox Studio 專案「AI Adventure Island」。
現有背景:
- 玩家已有 leaderstats.Gold。
- QuestService 將 GuideQuestState 儲存為玩家 attribute。
- ShopService 將 WindDashCooldownLevel 與 WindDashSpeedLevel 儲存為玩家 attribute。
- AbilityService 會讀取這些升級 attribute。
- 本章不要加入交易、背包、排行榜、OrderedDataStore 或 analytics。
建立第一版持久化存檔系統:
1. 建立 ServerScriptService.SaveService。
2. 只在這個 server Script 中使用 DataStoreService。
3. 開發測試使用以下 DataStore 名稱:
- AIAdventureIsland_PlayerData_DEV_v1
4. 玩家資料的 key 使用:
- "Player_" .. player.UserId
5. 將所有玩家進度存入同一個 table:
- SchemaVersion
- Gold
- GuideQuestState
- Upgrades.WindDashCooldownLevel
- Upgrades.WindDashSpeedLevel
6. 提供一個建立預設資料的 function:
- SchemaVersion = 1
- Gold = 0
- GuideQuestState = "NotStarted"
- WindDashCooldownLevel = 0
- WindDashSpeedLevel = 0
7. 在 Players.PlayerAdded 時:
- 在 pcall 內使用 GetAsync 讀取玩家資料
- 如果資料存在且型別是 table,就套用該資料
- 如果資料不存在,就套用預設資料
- 如果 GetAsync 失敗,套用預設資料並在 Output 顯示警告
8. 套用資料時應:
- 確保 leaderstats.Gold 存在
- 設定 Gold.Value
- 設定玩家的 GuideQuestState、WindDashCooldownLevel、WindDashSpeedLevel attributes
9. 在 Players.PlayerRemoving 時:
- 將目前的 Gold 與 attributes 整理成同一個 table
- 在 pcall 內使用 UpdateAsync 或 SetAsync 保存
- 成功時印出訊息,失敗時顯示警告
10. 加入 game:BindToClose,讓 server 關閉時嘗試保存所有仍在線上的玩家。
11. 不要在每次 Gold 改變時保存。
12. 不要建立任何讓 client 請求保存的 RemoteEvent。
13. 現階段不要使用 production store 名稱。
另外加入註解說明:
- 如何啟用 Studio API access 以進行測試
- 為什麼使用 DEV store 名稱
- 為什麼 DataStore 呼叫需要 pcall
這個 prompt 比一般「幫我做存檔」長很多,原因是存檔系統的錯誤代價比較高。Assistant 如果只生成 happy path,Play Solo 看起來可能正常,但一遇到 throttle、API 未啟用、server 關閉或資料格式改變,就會出問題。
這句尤其重要:
不要建立任何讓 client 請求保存的 RemoteEvent。
玩家不需要告訴 server「請幫我存檔」。server 本來就知道權威資料,應該由 server 決定何時保存。
Assistant 可能建立:
ServerScriptService
└── SaveService
第一版 SaveService 可能像這樣:
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local STORE_NAME = "AIAdventureIsland_PlayerData_DEV_v1"
local DATA_SCHEMA_VERSION = 1
local playerDataStore = DataStoreService:GetDataStore(STORE_NAME)
local function getPlayerKey(player)
return "Player_" .. player.UserId
end
local function getDefaultData()
return {
SchemaVersion = DATA_SCHEMA_VERSION,
Gold = 0,
GuideQuestState = "NotStarted",
Upgrades = {
WindDashCooldownLevel = 0,
WindDashSpeedLevel = 0,
},
}
end
applyData() 負責把讀出的資料套回目前 server 狀態:
local function ensureLeaderstats(player)
local leaderstats = player:FindFirstChild("leaderstats")
if not leaderstats then
leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
leaderstats.Parent = player
end
local gold = leaderstats:FindFirstChild("Gold")
if not gold then
gold = Instance.new("IntValue")
gold.Name = "Gold"
gold.Value = 0
gold.Parent = leaderstats
end
return leaderstats, gold
end
local function applyData(player, data)
local _, gold = ensureLeaderstats(player)
gold.Value = tonumber(data.Gold) or 0
player:SetAttribute("GuideQuestState", data.GuideQuestState or "NotStarted")
local upgrades = data.Upgrades or {}
player:SetAttribute("WindDashCooldownLevel", tonumber(upgrades.WindDashCooldownLevel) or 0)
player:SetAttribute("WindDashSpeedLevel", tonumber(upgrades.WindDashSpeedLevel) or 0)
end
collectData() 則把目前 server 狀態整理成可保存的 table:
local function collectData(player)
local leaderstats = player:FindFirstChild("leaderstats")
local gold = leaderstats and leaderstats:FindFirstChild("Gold")
return {
SchemaVersion = DATA_SCHEMA_VERSION,
Gold = gold and gold.Value or 0,
GuideQuestState = player:GetAttribute("GuideQuestState") or "NotStarted",
Upgrades = {
WindDashCooldownLevel = player:GetAttribute("WindDashCooldownLevel") or 0,
WindDashSpeedLevel = player:GetAttribute("WindDashSpeedLevel") or 0,
},
}
end
載入時要用 pcall:
local function loadPlayerData(player)
local key = getPlayerKey(player)
local success, data = pcall(function()
return playerDataStore:GetAsync(key)
end)
if success and typeof(data) == "table" then
applyData(player, data)
print("Loaded data for", player.Name)
return
end
if success and data == nil then
applyData(player, getDefaultData())
print("Applied default data for", player.Name)
return
end
warn("Failed to load data for", player.Name, data)
applyData(player, getDefaultData())
end
保存時可以先用 UpdateAsync 保存整個 object:
local function savePlayerData(player)
local key = getPlayerKey(player)
local dataToSave = collectData(player)
local success, errorMessage = pcall(function()
playerDataStore:UpdateAsync(key, function(_oldData)
return dataToSave
end)
end)
if success then
print("Saved data for", player.Name)
else
warn("Failed to save data for", player.Name, errorMessage)
end
end
最後接上玩家生命週期:
Players.PlayerAdded:Connect(loadPlayerData)
Players.PlayerRemoving:Connect(function(player)
savePlayerData(player)
end)
game:BindToClose(function()
for _, player in Players:GetPlayers() do
savePlayerData(player)
end
end)
這是第一版存檔,不是最終架構。它還沒有 retry queue、session lock、資料遷移、多 profile、離線回復等功能。但對本書目前的遊戲進度來說,它已經足夠建立正確觀念:資料集中、server 權威、失敗處理、測試資料隔離。
DataStoreService 是 Roblox 提供的持久化儲存服務。它可以讓資料跨 session 保存,例如玩家金幣、升級、任務進度。
本章只在 ServerScriptService.SaveService 使用它:
local DataStoreService = game:GetService("DataStoreService")
不要在 LocalScript 使用 DataStoreService。client 不應該直接讀寫持久資料。
本章使用:
AIAdventureIsland_PlayerData_DEV_v1
命名包含三件事:
AIAdventureIsland
PlayerData
DEV_v1
等你準備發布正式版時,可以建立正式 store:
AIAdventureIsland_PlayerData_PROD_v1
不要只是把測試資料拿去當正式資料。測試時你可能手動加錢、重複購買、破壞 schema,這些資料不應成為玩家正式進度。
每位玩家使用一個 key:
"Player_" .. player.UserId
這比使用玩家名稱穩定。玩家名稱可能變更,UserId 才是帳號的長期識別。
DataStore key 有長度限制,所以 key 要簡短、可預期。
本章把 Gold、任務狀態、升級狀態存在同一個 table。
不要一開始就拆成:
GoldStore
QuestStore
UpgradeStore
拆太多 store 會增加一致性問題。例如 Gold 扣了,但升級狀態保存失敗,玩家下次回來就會少錢又沒升級。單一 player data object 比較容易保持一致。
DataStore 呼叫可能失敗。原因可能是:
所以不要這樣寫:
local data = playerDataStore:GetAsync(key)
要這樣:
local success, data = pcall(function()
return playerDataStore:GetAsync(key)
end)
pcall 不是讓錯誤消失,而是讓你有機會記錄錯誤、套用 fallback,避免整個 script 因為一次失敗中斷。
GetAsync 讀取資料。
SetAsync 直接寫入資料,簡單但遇到多 server 同時寫同一 key 時比較容易覆蓋。
UpdateAsync 讀目前值、執行 callback、再寫入新值。它比較慢,也消耗讀寫 budget,但更適合處理同一 key 可能被多個 server 更新的情況。
本章使用 UpdateAsync 保存整個 player data object。callback 裡不要 task.wait() 或呼叫會 yield 的函式。
要在 Studio 測 DataStore,必須:
File -> Experience Settings。Security。Enable Studio Access to API Services。但這個設定有風險。Studio 會連到同一個 experience 的 DataStore,所以正式遊戲不要直接用 Studio 測 production store。最安全的流程是建立測試版 experience 或使用 DEV store 名稱。
玩家正常離開時會觸發 Players.PlayerRemoving。但 server 關閉時,仍然可能有玩家在線上。
本章用:
game:BindToClose(function()
for _, player in Players:GetPlayers() do
savePlayerData(player)
end
end)
這能提高 server shutdown 時保存資料的機率。不過它不是萬能保證,所以重要遊戲通常還會加定期 autosave 與 retry。這些可以放到後面工程整理章節。
DataStore 測試要比一般功能更謹慎。
第一輪:未啟用 API access
這一輪的目的,是讓讀者知道「API 沒開」不是 script 一定寫錯。
第二輪:DEV store 載入預設資料
AIAdventureIsland_PlayerData_DEV_v1。Gold 應為 0。GuideQuestState 應為 NotStarted。第三輪:保存與重新載入
PlayerRemoving 保存。第四輪:server shutdown
第五輪:壞資料防護
如果你透過 Data Stores Manager 手動修改測試資料,故意少掉 Upgrades table,重新進入時不應直接 error。applyData() 應使用 default fallback。
如果 Assistant 在 LocalScript 使用 DataStoreService,使用:
【Ch18 修正 1|移除 Client DataStore】
目前有 LocalScript 在使用 DataStoreService。
預期結果:
- DataStoreService 只能在 ServerScriptService.SaveService 中使用。
- Client 不應直接讀取或寫入 DataStore。
- 不應有任何 RemoteEvent 讓 client 請求保存。
請將所有 DataStore 程式碼移到 server Script,並讓 client 完全不參與存檔流程。
如果沒有 pcall,使用:
【Ch18 修正 2|補上 DataStore pcall】
SaveService 呼叫 GetAsync、SetAsync 或 UpdateAsync 時沒有使用 pcall。
預期結果:
- 每一次 DataStore request 都應包在 pcall 中。
- 載入失敗時,套用預設資料並在 Output 顯示警告。
- 保存失敗時,在 Output 顯示警告,且不要讓 script crash。
請只更新 SaveService。
如果每次 Gold 改變都保存,使用:
【Ch18 修正 3|停止逐次寫入 DataStore】
SaveService 在每次 Gold 改變時都寫入 DataStore。
預期結果:
- 不要在每次 Gold 改變時保存。
- 在 PlayerRemoving 時保存。
- 在 BindToClose 時保存。
- 之後可以選擇加入間隔較長的 autosave,但不能在每次數值改變時保存。
請移除數值一改變就立即寫入的行為,以避免 DataStore throttling。
如果測試資料和正式資料混在一起,使用:
【Ch18 修正 4|改用 DEV DataStore】
目前仍在 Studio 測試,但存檔系統使用了看起來像正式環境的 DataStore 名稱。
預期結果:
- 本章使用 AIAdventureIsland_PlayerData_DEV_v1。
- 加入註解,說明只有完成 release testing 後才能使用 PROD store 名稱。
- 本章不要進行資料遷移。
如果重新進入後升級沒有載回,使用:
【Ch18 修正 5|載回 WindDash 升級】
Gold 可以正確載入,但玩家重新加入後,WindDash 升級 attributes 沒有載入。
預期結果:
- SaveService 應保存並載入:
- WindDashCooldownLevel
- WindDashSpeedLevel
- applyData 應設定這些玩家 attributes。
- collectData 應從玩家讀取這些 attributes。
請只檢查 SaveService。
完成本章後,Explorer 應接近這樣:
ServerScriptService
├── QuestService
├── EnemyAIService
├── AbilityService
├── ShopService
└── SaveService
SaveService 不應該在 client,也不應該散落到 QuestService、ShopService 或 AbilityService 裡。
整理時檢查:
DEV_v1。Player_<UserId>。pcall。UpdateAsync callback 不 yield。本章完成後,第四部的主線功能已經有一個完整閉環:
任務給 Gold
Gold 買升級
升級強化能力
能力幫助玩家通過敵人區
進度可以保存
本章讓 AI Adventure Island 第一次具備跨 session 進度。
你建立了:
SaveService。AIAdventureIsland_PlayerData_DEV_v1。pcall 錯誤處理。最重要的觀念是:DataStore 不是一般變數。它是有限制、會失敗、需要隔離測試資料的雲端服務。
下一章會進入第五部,開始整理 AI 生成後的專案架構。到目前為止,我們已經用 Assistant 生成了很多功能;接下來要把這些 service、module、remote、UI controller 重新檢查成可維護的長期結構。
第八部會再回到存檔邊界:第 34 章處理 receipt 與 entitlement,第 36–37 章則只觀測成功交易,不讓 analytics 取代可靠保存。
嗨!我是 Wolke,曾任 Google Developer Expert(GDE,2019–2023) 與 LINE API Expert。
我熱衷於研究 AI Agent、n8n 自動化工作流與全端開發架構,致力於將 AI 技術轉化為真正能落地的生產力工具。
如果你喜歡這篇文章,歡迎透過以下方式與我交流:
📚 技術著作
《實用的 Gemini API 開發點子書》:帶你運用 Gemini App、Google AI Studio、Gemini CLI 與 Antigravity IDE,打造 AI Agent 與實用產品。
📝 技術部落格
歡迎追蹤我的 Medium,我會持續分享 Agentic Automation、架構設計與實際開發的踩坑心得。
🎤 技術講座與合作
我持續受邀至技術社群及研討會,分享 AI Agent、自動化工作流、DevOps 與全端開發實戰。
我曾於 DevOpsDays Taipei 2026 主講「不再只是寫腳本!讓 AI 代理人成為你的 SRE 最佳夥伴」工作坊。
如果你的企業、社群或學校正在尋找相關主題講者,歡迎私訊聯繫,洽談講座與工作坊合作!
🎮 我的 Roblox 遊戲
🎁 免費贈送 OpenAI 或 Claude AI 額度
為了鼓勵大家實際動手打造自己的 Roblox 體驗,我每個月會開放:
參加方式:
確認完成後,我會邀請你加入並設定 50 點額度。名額有限,歡迎把握機會!